summaryrefslogtreecommitdiff
path: root/app/[lng]/pdftron-viewer/page.tsx
diff options
context:
space:
mode:
Diffstat (limited to 'app/[lng]/pdftron-viewer/page.tsx')
-rw-r--r--app/[lng]/pdftron-viewer/page.tsx507
1 files changed, 507 insertions, 0 deletions
diff --git a/app/[lng]/pdftron-viewer/page.tsx b/app/[lng]/pdftron-viewer/page.tsx
new file mode 100644
index 00000000..bde60a41
--- /dev/null
+++ b/app/[lng]/pdftron-viewer/page.tsx
@@ -0,0 +1,507 @@
+// app/pdftron-viewer/page.tsx
+
+"use client"
+
+import * as React from "react"
+import { useSearchParams } from "next/navigation"
+import { Button } from "@/components/ui/button"
+import { ArrowLeft, MessageSquare, Download, Upload } from "lucide-react"
+import { Badge } from "@/components/ui/badge"
+import { useSession } from "next-auth/react"
+import { useToast } from "@/hooks/use-toast"
+import type { WebViewerInstance } from "@pdftron/webviewer"
+
+// PDFTron 코멘트 타입 정의
+interface PDFTronComment {
+ id: number
+ documentReviewId: number
+ pdftronDocumentId: string
+ xfdfString: string
+ annotationData: any
+ commentSummary?: {
+ total: number
+ open: number
+ resolved: number
+ rejected: number
+ deferred: number
+ byCategory: Record<string, number>
+ bySeverity: Record<string, number>
+ byAuthor: Record<string, number>
+ }
+ createdBy: number
+ createdByName?: string
+ createdByType: "buyer" | "vendor"
+ createdAt: Date
+ updatedAt: Date
+}
+
+export default function PDFTronViewerPage() {
+ const { data: session, status } = useSession()
+ const searchParams = useSearchParams()
+ const viewerRef = React.useRef<HTMLDivElement>(null)
+ const [instance, setInstance] = React.useState<WebViewerInstance | null>(null)
+ const [isLoading, setIsLoading] = React.useState(true)
+ const [lastSavedTime, setLastSavedTime] = React.useState<Date | null>(null)
+ const [isSaving, setIsSaving] = React.useState(false)
+ const [annotationCount, setAnnotationCount] = React.useState(0)
+ const { toast } = useToast()
+ const initialized = React.useRef(false)
+ const isCancelled = React.useRef(false)
+ const autoSaveTimerRef = React.useRef<NodeJS.Timeout | null>(null)
+ const xfdfLoadedRef = React.useRef(false) // XFDF 로딩 완료 여부 추적
+
+ // URL 파라미터에서 정보 가져오기
+ const filePath = searchParams.get('filePath')
+ const documentId = searchParams.get('documentId')
+ const documentReviewId = searchParams.get('documentReviewId')
+ const sessionId = searchParams.get('sessionId')
+ const documentName = searchParams.get('documentName')
+
+ // PDFTron WebViewer 초기화 - session과 XFDF 모두 준비된 후 실행
+ React.useEffect(() => {
+ if (!initialized.current && viewerRef.current && filePath && session && documentReviewId) {
+ initialized.current = true
+ isCancelled.current = false
+
+ // XFDF 먼저 로드한 후 WebViewer 초기화
+ loadAndInitializeViewer()
+ }
+
+ return () => {
+ if (instance) {
+ try {
+ instance.UI.dispose()
+ } catch (error) {
+ console.warn("Error disposing viewer:", error)
+ }
+ }
+ isCancelled.current = true
+
+ // 타이머 정리
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+ }
+ }, [filePath, session, documentReviewId, sessionId])
+
+ const loadAndInitializeViewer = async () => {
+ try {
+ // 1. 먼저 기존 XFDF 로드
+ let existingXFDF = ""
+ try {
+ const response = await fetch(`/api/pdftron-comments/xfdf?documentReviewId=${documentReviewId}`)
+ if (response.ok) {
+ const data = await response.json()
+ if (data.xfdfString) {
+ existingXFDF = data.xfdfString
+ console.log("Loaded existing XFDF successfully")
+ }
+ }
+ } catch (error) {
+ console.error("Failed to load XFDF:", error)
+ }
+
+ // 2. WebViewer 초기화
+ await initializeWebViewer(existingXFDF)
+
+ } catch (error) {
+ console.error("Failed to initialize viewer:", error)
+ setIsLoading(false)
+ toast({
+ title: "Error",
+ description: "Failed to initialize document viewer",
+ variant: "destructive"
+ })
+ }
+ }
+
+ const initializeWebViewer = async (existingXFDF: string) => {
+ try {
+ console.log("Starting WebViewer initialization...")
+ console.log("File path:", filePath)
+ console.log("Current session:", session)
+ console.log("Has existing XFDF:", !!existingXFDF)
+
+ // 동적 import 사용
+ const { default: WebViewer } = await import("@pdftron/webviewer")
+
+ if (isCancelled.current || !viewerRef.current) {
+ console.log("WebViewer initialization cancelled")
+ return
+ }
+
+ // WebViewer 인스턴스 생성
+ const webviewerInstance = await WebViewer(
+ {
+ path: "/pdftronWeb",
+ licenseKey: process.env.NEXT_PUBLIC_PDFTRON_LICENSE_KEY || process.env.NEXT_PUBLIC_PDFTRON_WEBVIEW_KEY,
+ initialDoc: filePath!,
+ },
+ viewerRef.current
+ )
+
+ if (isCancelled.current) {
+ console.log("WebViewer initialization cancelled after creation")
+ return
+ }
+
+ setInstance(webviewerInstance)
+
+ if (!webviewerInstance.Core) {
+ console.error("WebViewer Core is not available")
+ setIsLoading(false)
+ return
+ }
+
+ const { documentViewer, annotationManager, Annotations } = webviewerInstance.Core
+
+ // 현재 사용자 설정
+ const currentUser = session?.user?.email || session?.user?.name || 'Anonymous'
+ console.log("Setting current user:", currentUser)
+ annotationManager.setCurrentUser(currentUser)
+
+ // 권한 설정 - 자기 annotation만 수정/삭제 가능
+ annotationManager.setPermissionCheckCallback((author: string, annotation: any) => {
+ // 자기가 만든 annotation만 수정 가능
+ return author === currentUser
+ })
+
+ // 문서 로드 완료 시
+ documentViewer.addEventListener('documentLoaded', async () => {
+ console.log("Document loaded successfully")
+ setIsLoading(false)
+
+ console.log(existingXFDF)
+
+ // 기존 XFDF 적용
+ if (existingXFDF && !xfdfLoadedRef.current) {
+ console.log(existingXFDF, "existingXFDF")
+
+ try {
+ await annotationManager.importAnnotations(existingXFDF)
+ xfdfLoadedRef.current = true
+ console.log("Imported existing annotations from XFDF")
+
+ // 초기 annotation 수 설정
+ const annotations = annotationManager.getAnnotationsList()
+ setAnnotationCount(annotations.length)
+
+ // 마지막 저장 시간 설정
+ setLastSavedTime(new Date())
+ } catch (error) {
+ console.error("Failed to import XFDF:", error)
+ toast({
+ title: "Warning",
+ description: "Failed to load existing annotations",
+ variant: "destructive"
+ })
+ }
+ }
+
+ // UI 설정 (1초 지연)
+ setTimeout(() => {
+ setupUI()
+ }, 1000)
+ })
+
+ // UI 설정 함수
+ const setupUI = async () => {
+ try {
+ console.log("Setting up UI features...")
+
+ // Review 모드 annotation 도구 활성화
+ try {
+ // 주석 도구 활성화
+ webviewerInstance.UI.enableElements(['highlightToolButton'])
+ webviewerInstance.UI.enableElements(['stickyToolButton'])
+ webviewerInstance.UI.enableElements(['freeTextToolButton'])
+ webviewerInstance.UI.enableElements(['underlineToolButton'])
+ webviewerInstance.UI.enableElements(['strikeoutToolButton'])
+ webviewerInstance.UI.enableElements(['squigglyToolButton'])
+
+ // 노트 패널 열기
+ webviewerInstance.UI.openElements(['notesPanel'])
+ } catch (e) {
+ console.log("Could not enable annotation tools:", e)
+ }
+
+ // 커스텀 이벤트 리스너 설정
+ setupAnnotationListeners()
+ } catch (error) {
+ console.error("Error setting up UI:", error)
+ }
+ }
+
+ // Annotation 이벤트 리스너 설정
+ const setupAnnotationListeners = () => {
+ // 자동 저장 함수
+ const handleAutoSave = async () => {
+ if (!documentReviewId) {
+ console.log("No documentReviewId, skipping auto-save")
+ return
+ }
+
+ // 이미 저장 중이면 스킵
+ if (isSaving) {
+ console.log("Already saving, skipping...")
+ return
+ }
+
+ setIsSaving(true)
+
+ try {
+ const xfdfString = await annotationManager.exportAnnotations()
+
+ // Annotation 요약 정보 생성
+ const annotations = annotationManager.getAnnotationsList()
+ const summary = {
+ total: annotations.length,
+ open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length,
+ resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length,
+ rejected: annotations.filter((a: any) => a.getCustomData('status') === 'rejected').length,
+ deferred: annotations.filter((a: any) => a.getCustomData('status') === 'deferred').length,
+ byCategory: {} as Record<string, number>,
+ bySeverity: {} as Record<string, number>,
+ byAuthor: {} as Record<string, number>
+ }
+
+ annotations.forEach((annotation: any) => {
+ const category = annotation.getCustomData('category') || 'general'
+ const severity = annotation.getCustomData('severity') || 'minor'
+ const author = annotation.Author || 'Anonymous'
+
+ summary.byCategory[category] = (summary.byCategory[category] || 0) + 1
+ summary.bySeverity[severity] = (summary.bySeverity[severity] || 0) + 1
+ summary.byAuthor[author] = (summary.byAuthor[author] || 0) + 1
+ })
+
+ // 서버에 저장
+ const response = await fetch('/api/pdftron-comments/xfdf', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({
+ documentReviewId: parseInt(documentReviewId),
+ sessionId: sessionId ? parseInt(sessionId) :0,
+ pdftronDocumentId: documentId,
+ xfdfString: xfdfString,
+ commentSummary: summary,
+ createdByType: 'buyer'
+ })
+ })
+
+ if (response.ok) {
+ setLastSavedTime(new Date())
+ setAnnotationCount(annotations.length)
+ console.log("Auto-save successful")
+ } else {
+ console.error("Auto-save failed")
+ toast({
+ title: "Error",
+ description: "Failed to save annotations",
+ variant: "destructive"
+ })
+ }
+ } catch (error) {
+ console.error("Auto-save error:", error)
+ toast({
+ title: "Error",
+ description: "Failed to save annotations",
+ variant: "destructive"
+ })
+ } finally {
+ setIsSaving(false)
+ }
+ }
+
+ // Annotation 변경 감지
+ annotationManager.addEventListener('annotationChanged', (annotations: any[], action: string) => {
+ if (action === 'add' || action === 'modify' || action === 'delete') {
+ // 새 annotation에 기본 메타데이터 추가
+ if (action === 'add') {
+ annotations.forEach(annotation => {
+ if (!annotation.getCustomData('category')) {
+ annotation.setCustomData('category', 'general')
+ annotation.setCustomData('severity', 'minor')
+ annotation.setCustomData('status', 'open')
+ annotation.setCustomData('createdBy', session?.user?.id || '')
+ annotation.setCustomData('createdByType', 'buyer')
+ annotation.setCustomData('createdAt', new Date().toISOString())
+
+ // 기본 색상 설정 (minor = yellow)
+ try {
+ if (Annotations) {
+ annotation.Color = new Annotations.Color(250, 204, 21)
+ }
+ } catch (e) {
+ console.log("Could not set annotation color")
+ }
+ }
+ })
+ }
+
+ // 자동 저장 - 2초 디바운싱
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+
+ autoSaveTimerRef.current = setTimeout(() => {
+ console.log("Auto-saving annotations...")
+ handleAutoSave()
+ }, 2000)
+ }
+ })
+
+ // 코멘트 변경 감지
+ annotationManager.addEventListener('annotationCommentsChanged', () => {
+ // 자동 저장 - 1.5초 디바운싱
+ if (autoSaveTimerRef.current) {
+ clearTimeout(autoSaveTimerRef.current)
+ }
+
+ autoSaveTimerRef.current = setTimeout(() => {
+ console.log("Auto-saving comments...")
+ handleAutoSave()
+ }, 1500)
+ })
+
+ // Annotation 선택 시 기본값 설정
+ annotationManager.addEventListener('annotationSelected', (annotations: any, action: string) => {
+ if (annotations && annotations.length > 0) {
+ const annotation = annotations[0]
+
+ // 기본 커스텀 데이터 설정
+ if (!annotation.getCustomData('category')) {
+ annotation.setCustomData('category', 'general')
+ annotation.setCustomData('severity', 'minor')
+ annotation.setCustomData('status', 'open')
+ annotation.setCustomData('createdBy', session?.user?.id || '')
+ annotation.setCustomData('createdByType', 'buyer')
+ annotation.setCustomData('createdAt', new Date().toISOString())
+ }
+ }
+ })
+ }
+
+ } catch (error) {
+ console.error("WebViewer initialization failed:", error)
+ setIsLoading(false)
+ toast({
+ title: "Error",
+ description: "Failed to initialize document viewer",
+ variant: "destructive"
+ })
+ }
+ }
+
+
+
+ // 통계 정보 가져오기
+ const getAnnotationStats = () => {
+ if (!instance) return null
+
+ const { annotationManager } = instance.Core
+ const annotations = annotationManager.getAnnotationsList()
+
+ return {
+ total: annotations.length,
+ open: annotations.filter((a: any) => a.getCustomData('status') !== 'resolved').length,
+ resolved: annotations.filter((a: any) => a.getCustomData('status') === 'resolved').length
+ }
+ }
+
+ // 시간 포맷팅
+ const formatLastSaved = () => {
+ if (!lastSavedTime) return null
+
+ const now = new Date()
+ const diff = Math.floor((now.getTime() - lastSavedTime.getTime()) / 1000)
+
+ if (diff < 60) return "Just saved"
+ if (diff < 3600) return `Saved ${Math.floor(diff / 60)} min ago`
+ if (diff < 86400) return `Saved ${Math.floor(diff / 3600)} hours ago`
+ return `Saved ${Math.floor(diff / 86400)} days ago`
+ }
+
+ const stats = getAnnotationStats()
+ const lastSavedText = formatLastSaved()
+
+ return (
+ <div className="flex flex-col h-screen overflow-hidden">
+ {/* Header */}
+ <div className="flex items-center justify-between p-4 border-b bg-background flex-shrink-0">
+ <div className="flex items-center gap-4">
+ <Button
+ variant="ghost"
+ size="sm"
+ onClick={() => window.close()}
+ >
+ <ArrowLeft className="h-4 w-4 mr-2" />
+ Back
+ </Button>
+ <div>
+ <h1 className="text-lg font-semibold">{documentName || 'Document Viewer'}</h1>
+ <div className="flex items-center gap-2 text-sm text-muted-foreground">
+ <span>Review Mode</span>
+ <span>•</span>
+ <span>User: {session?.user?.email || session?.user?.name || 'Loading...'}</span>
+ {stats && stats.total > 0 && (
+ <>
+ <span>•</span>
+ <Badge variant="outline">
+ <MessageSquare className="h-3 w-3 mr-1" />
+ {stats.open} open / {stats.total} total
+ </Badge>
+ </>
+ )}
+ {isSaving && (
+ <>
+ <span>•</span>
+ <Badge variant="outline" className="text-blue-600 border-blue-600">
+ <div className="animate-pulse">Auto-saving...</div>
+ </Badge>
+ </>
+ )}
+ {!isSaving && lastSavedText && (
+ <>
+ <span>•</span>
+ <Badge variant="outline" className="text-green-600 border-green-600">
+ ✓ {lastSavedText}
+ </Badge>
+ </>
+ )}
+ </div>
+ </div>
+ </div>
+
+ </div>
+
+ {/* PDFTron Viewer */}
+ <div className="flex-1 relative overflow-hidden">
+ {(isLoading || status === "loading") && (
+ <div className="absolute inset-0 flex items-center justify-center bg-background/80 z-10">
+ <div className="text-center">
+ <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary mx-auto mb-2"></div>
+ <p className="text-sm text-muted-foreground">
+ {status === "loading" ? "Loading session..." : "Loading document..."}
+ </p>
+ <p className="text-xs text-muted-foreground mt-1">
+ Initializing PDFTron viewer...
+ </p>
+ </div>
+ </div>
+ )}
+ <div
+ ref={viewerRef}
+ className="h-full w-full"
+ style={{
+ position: 'absolute',
+ top: 0,
+ left: 0,
+ right: 0,
+ bottom: 0,
+ }}
+ />
+ </div>
+ </div>
+ )
+} \ No newline at end of file